Thirty Days of Metal — Day 11: Meshes
This series of posts is my attempt to present the Metal graphics programming framework in small, bite-sized chunks for Swift app developers who haven’t done GPU programming before.
If you want to work through this series in order, start here.
If you’re anything like me, you’re probably tired of drawing a single triangle. Our triangle friend has served us well through our exploration of draw calls, vertex attributes, dynamic constants, and some pretty heady math.
It’s time to move on. This article will introduce the concept of the mesh, a unit of geometry that can be drawn with a single draw call. In addition to seeing how to draw a lot more triangles, we will see how to use indexing to store such geometry much more efficiently.
Primitives, Connectivity, and Meshes
Unlike most graphics libraries, Metal doesn’t provide high-level APIs for drawing curves, text, or polygons. Everything we draw with Metal has to be broken down into constituent parts called primitives.
We have already become familiar with one Metal primitive: the triangle. However, there are other primitive types we should get acquainted with.
Looking at the declaration of MTLPrimitiveType indicates there are five basic primitives we can draw with Metal:
enum MTLPrimitiveType : UInt {
case point = 0
case line = 1
case lineStrip = 2
case triangle = 3
case triangleStrip = 4
}The primitive type of a draw call indicates how vertices are combined together into these shapes. In the case of .point, each vertex is rendered as a small screen-aligned square. The .line primitive takes pairs of vertices and joins them into line segments. A line strip (.lineStrip) is a connected sequences of line segments, where each vertex after the first one defines the endpoint of the next segment. A triangle (.triangle) is a polygon that consists of three vertices. A triangle strip (.triangleStrip) is a connected group of triangles, where each vertex after the first two defines a triangle along with the previous two vertices.
These primitive types are shown in the figure below, which also indicates how the numbered vertices would be connected together to form each type of primitive.
We call the way that vertices are joined together to make primitives their connectivity. By default, the order of vertices in the vertex buffer is used to determine which vertices are joined together, but we will see a technique below for influencing this.
A mesh, (or polygon mesh) is the set of vertices, edges, and faces that comprise a 2D or 3D object. Let’s take a look at how we represent this abstract notion in code.
A Simple Mesh Class
Since we want to expand the abilities of our renderer beyond a single triangle, it is natural to create an abstraction that will help us manage many triangle as a unit.
We will call our basic mesh class SimpleMesh. The main purpose of this class is to gather up all of the data we use in a draw call into a single object.
For the time being, we will make the assumption that a mesh consists only of triangle data. A mesh can have multiple vertex buffers, each holding the data for one or more attributes. The layout of these buffers is described by the vertex descriptor. We also store the mesh’s vertex count.
class SimpleMesh {
let vertexBuffers: [MTLBuffer]
let vertexDescriptor: MTLVertexDescriptor
let vertexCount: Int
let primitiveType: MTLPrimitiveType = .triangle
// …We define an initializer that sets up the members of the mesh:
init(vertexBuffers: [MTLBuffer],
vertexDescriptor: MTLVertexDescriptor,
vertexCount: Int)This mesh class is indeed quite simple so far, but it has everything we need to start defining polygon meshes that we can draw with Metal.
Generating Meshes Programmatically
Suppose we want to provide a convenience initializer that creates a regular polygon mesh, such as this pentagon:
To draw polygons with Metal, we need to decompose them into triangles. In this case, the pentagon consists of five triangles. Since the vertices (except for the center) are evenly spaced along the circumference of a circle, we can generate their positions in a loop using trigonometric functions.
For a regular polygon with a given radius and side count, we can enumerate the vertex positions like this:
var angle: Float = .pi / 2
let deltaAngle = (2 * .pi) / Float(sideCount)
for _ in 0..<sideCount {
let x = radius * cos(angle)
let y = radius * sin(angle)
// …
angle += deltaAngle
}Our initializer will take the polygon side count, radius, a solid color, and a device (which will allow us to allocate vertex buffers).
convenience init(planarPolygonSideCount sideCount: Int,
radius: Float,
color: SIMD4<Float>,
device: MTLDevice)Unlike in previous samples, we will store the positions and colors in separate buffers, so we declare two arrays to accumulate them:
{
var positions = [SIMD2<Float>]()
var colors = [SIMD4<Float>]()
// …We then generate our positions and colors using a loop like the one sketched out previously. Since each side of the polygon corresponds to a triangle, we generate three vertices per loop iteration:
var angle: Float = .pi / 2
let deltaAngle = (2 * .pi) / Float(sideCount)
for _ in 0..<sideCount {
positions.append(SIMD2<Float>(radius * cos(angle),
radius * sin(angle)))
colors.append(color) positions.append(SIMD2<Float>(radius * cos(angle + deltaAngle),
radius * sin(angle + deltaAngle)))
colors.append(color) positions.append(SIMD2<Float>(0, 0))
colors.append(color) angle += deltaAngle
}
Now that we have our positions and colors, we can allocate some vertex buffers to store them, following a now-familiar pattern:
let positionBuffer = device.makeBuffer(
bytes: positions,
length: MemoryLayout<SIMD2<Float>>.stride * positions.count,
options: .storageModeShared)!let colorBuffer = device.makeBuffer(
bytes: colors,
length: MemoryLayout<SIMD4<Float>>.stride * colors.count,
options: .storageModeShared)!
We finish up our convenience initializer by delegating to the initializer we previously declared:
self.init(vertexBuffers: [positionBuffer, colorBuffer],
vertexDescriptor: SimpleMesh.defaultVertexDescriptor,
vertexCount: positions.count)
}The “default vertex descriptor” referenced above is a vertex descriptor that is common to all of the simple meshes we will build. It contains attributes for positions and colors, and two layouts that correspond to the two vertex buffers we allocate for each mesh:
private static var defaultVertexDescriptor: MTLVertexDescriptor {
let vertexDescriptor = MTLVertexDescriptor()
vertexDescriptor.attributes[0].format = .float2
vertexDescriptor.attributes[0].offset = 0
vertexDescriptor.attributes[0].bufferIndex = 0
vertexDescriptor.attributes[1].format = .float4
vertexDescriptor.attributes[1].offset = 0
vertexDescriptor.attributes[1].bufferIndex = 1
vertexDescriptor.layouts[0].stride = MemoryLayout<SIMD2<Float>>.stride
vertexDescriptor.layouts[1].stride = MemoryLayout<SIMD4<Float>>.stride
return vertexDescriptor
}This is the first time we have used more than one vertex buffer, so it’s worth considering how this vertex descriptor differs from earlier ones.
Updating the Renderer to Draw Meshes
Now that our renderer no longer holds vertex buffers, we need to adapt its draw method to use meshes. Suppose our renderer has a member that holds a mesh:
let mesh: SimpleMeshWe can initialize a pentagon mesh when creating the renderer:
mesh = SimpleMesh(planarPolygonSideCount: 5,
radius: 250,
color: SIMD4<Float>(0.0, 0.5, 0.8, 1.0),
device: device)When it comes time to draw, we need to bind all of the mesh’s vertex buffers into different slots. We use the enumerated() method of Array to get an (Int, MTLBuffer) tuple for each buffer, then set it on the render command encoder:
for (i, vertexBuffer) in mesh.vertexBuffers.enumerated() {
renderCommandEncoder.setVertexBuffer(vertexBuffer,
offset: 0,
index: i)
}Since we are using multiple buffers, we need to shift the index of our constant buffer so it doesn’t collide with our vertex buffers. We’ll use buffer index 2 for now:
renderCommandEncoder.setVertexBuffer(
constantBuffer,
offset: currentConstantBufferOffset,
index: 2)A corresponding change must be made in the shader source as well.
Finally, when drawing, we use the primitive type and vertex count provided by our mesh object:
renderCommandEncoder.drawPrimitives(type: mesh.primitiveType,
vertexStart: 0,
vertexCount: mesh.vertexCount)We have successfully created a simple class that allows us to store all of the data for a mesh together, which will be important as we scale up the number and complexity of the objects we draw.
There is one minor problem that you might have noticed, however. Our polygon mesh has 15 vertices, but many of these vertices are exact duplicates. For example, the data for the center vertex is repeated five times in the buffers, which is somewhat wasteful.
This redundancy is illustrated below. Each vertex is labelled by its corresponding vertex ID. As you can see, each vertex is duplicated at least once.
Indexed Geometry
Redundant data storage becomes a more severe problem as meshes get more complex, so we need a strategy for tackling it. We will use a feature of Metal called indexed drawing.
Instead of duplicating vertices each time we want to include them in a primitive, we store each vertex exactly once, and use an index buffer to indicate which vertices belong to which primitives.
Below is an illustration of how we organize data for indexed drawing. Each vertex now has a unique label, and the indices of the vertices of each triangle are listed below. Because indices tend to be much smaller than vertices, this scheme can save a lot of space.
Let’s consider how we can extend our mesh class to support indexed drawing.
Indexed Geometry in Metal
Like vertices, indices are stored in buffers. We allocate index buffers using exactly the same MTLDevice methods we use to allocate vertex buffers. However, index buffers store different types of data.
Metal supports two index types: uint16 and uint32. These two unsigned integer types are 2 bytes and 4 bytes in size, respectively. Before we create an index buffer, we need to know which of these types to use. uint16 can hold up to 65,534 different values, so it is suited to small- to medium-sized meshes. uint32 has an upper bound of several billion, so it will cover every other case.
We add the following members to the SimpleMesh class to allow it to optionally support indexed drawing:
let indexBuffer: MTLBuffer?
let indexType: MTLIndexType = .uint16
let indexCount: IntPretty straightforward. We store the index buffer and the type of indices it holds, along with the number of indices in the buffer.
We also define another initializer that allows us to create an indexed mesh:
init(vertexBuffers: [MTLBuffer], vertexDescriptor: MTLVertexDescriptor, vertexCount: Int,
indexBuffer: MTLBuffer, indexCount: Int)
{
self.vertexBuffers = vertexBuffers
self.vertexDescriptor = vertexDescriptor
self.vertexCount = vertexCount
self.indexBuffer = indexBuffer
self.indexCount = indexCount
}Generating Indexed Geometry
Let’s adapt our polygon mesh generator to make indexed geometry. The key idea is to only write each vertex into the vertex buffers once. Then, we can generate an index list that references the vertices efficiently.
The signature of the new convenience initializer looks similar to the previous one:
convenience init(
indexedPlanarPolygonSideCount sideCount: Int,
radius: Float,
color: SIMD4<Float>,
device: MTLDevice)As before, we generate the vertices by looping around the polygon’s enclosing circle. As a final step, we append the polygon’s center vertex.
var positions = [SIMD2<Float>]()
var colors = [SIMD4<Float>]()var angle: Float = .pi / 2
let deltaAngle = (2 * .pi) / Float(sideCount)
for _ in 0..<sideCount {
positions.append(SIMD2<Float>(radius * cos(angle),
radius * sin(angle)))
colors.append(color) angle += deltaAngle
}
positions.append(SIMD2<Float>(0, 0))
colors.append(color)
The vertex buffers are then generated exactly as before.
To build the index list, we loop around the polygon’s perimeter, appending three indices per triangle. Since we’re not likely to generate a 20,000-gon, we’ll use the smaller index type, which is represented in Swift as UInt16.
var indices = [UInt16]()
let count = UInt16(sideCount)
for i in 0..<count {
indices.append(i)
indices.append(count)
indices.append((i + 1) % count)
}Creating an index buffer looks just like creating a vertex buffer:
let indexBuffer = device.makeBuffer(
bytes: indices,
length: MemoryLayout<UInt16>.size * indices.count,
options: .storageModeShared)!Now that we have all of the necessary data, we can build an indexed mesh by delegating to the indexed mesh initializer:
self.init(vertexBuffers: [positionBuffer, colorBuffer],
vertexDescriptor: SimpleMesh.defaultVertexDescriptor,
vertexCount: positions.count,
indexBuffer: indexBuffer,
indexCount: indices.count)Indexed Draw Calls
Now we can create indexed meshes, but how do we draw them? Fortunately, the only difference between a non-indexed draw call and an indexed draw call is the method we call.
We call the drawIndexedPrimitives(type:indexCount: indexType:indexBuffer:indexBufferOffset:) method, providing the mesh values we used previously, along with the new ones we added for indexed drawing:
if let indexBuffer = mesh.indexBuffer {
renderCommandEncoder.drawIndexedPrimitives(
type: mesh.primitiveType,
indexCount: mesh.indexCount,
indexType: mesh.indexType,
indexBuffer: indexBuffer,
indexBufferOffset: 0)
}Running the sample code with a side count of 11 produces the following picture:
Apparently an 11-sided polygon is called a hendecagon. Who knew?
Next time, we will revisit the MetalKit framework and take a look at some utilities it provides for generating meshes.